Skip to content

Never patch the profiler's own import table - #721

Merged
zhengyu123 merged 1 commit into
DataDog:mainfrom
hisener:halil.sener/fix-library-patcher-self-patch
Aug 7, 2026
Merged

Never patch the profiler's own import table#721
zhengyu123 merged 1 commit into
DataDog:mainfrom
hisener:halil.sener/fix-library-patcher-self-patch

Conversation

@hisener

@hisener hisener commented Aug 6, 2026

Copy link
Copy Markdown
Member

This PR stops LibraryPatcher from patching the profiler's own import table, which could turn pthread_create_hook() into unbounded recursion and kill the JVM.

patch_library_unlocked() recognised its own library by comparing realpath(lib) with the profiler's path, and skipped that comparison entirely when realpath() returned nullptr (libraryPatcher_linux.cpp#L419-L426). dd-trace-java extracts libjavaProfiler.so to a temporary file and unlinks it once loaded, so realpath() on the still-mapped path fails, the self-check reports "not self", and a library re-scan patches our own GOT entry for pthread_create. pthread_create_hook() reaches the real pthread_create() through that same entry, so the hook then calls itself until the thread stack is exhausted: SIGSEGV, and no hs_err file, because crash reporting needs stack of its own. Whether it happens depends on a re-scan landing after the unlink, which is what made these crashes look random.

Diagnosed from core dumps of JVM test crashes:

  • the crashing thread's stack holds 3880-6136 identical pthread_create_hook+0xa8 frames (the return address of its own bl pthread_create@plt)
  • si_signo=11 si_code=128 (SI_KERNEL) si_addr=0x0, pc in malloc, and sp ~2MB below the thread's stack base
  • the profiler's own pthread_create JUMP_SLOT holds base+<pthread_create_hook> in every core inspected
  • crashing threads are always ones that create threads, during teardown, after every test passed

Reproduced at ~8-10% per run on a JVM test target with the profiler active. A standalone reproducer (unlink the extracted .so, dlopen to force a re-scan, then create threads) crashes dd-java-agent 1.65.0 downloaded straight from Maven Central, so this is not fixed in the latest release.

The fix

bool LibraryPatcher::is_profiler_library(CodeCache* lib) {
  return lib != nullptr && lib->contains(self_anchor());
}

self_anchor() returns the address of a function in this translation unit — a function rather than a static variable, because a CodeCache spans a library's executable segments (Symbols::parseLibraries builds the bounds from /proc/self/maps), which do not cover .data/.bss. Since every native library cache carries those bounds, there is no name comparison and no fallback: per review, falling back to something known to be faulty is not worth keeping, and the library deletion that triggers it is going away regardless.

Applied at all three patch sites: pthread_create, sigaction, and the socket functions.

patch_socket_functions() had the same hazard by another route. It computed its is-self flags in a pre-pass keyed by library index, then applied them in a second, locked pass that re-read native_libs.at(index). The array can grow between the two passes, so a flag could be applied to a different CodeCache than the one it was computed for — and a stale false on our own library would let us patch ourselves. That pre-pass only existed because the old check called realpath(), which must not run while holding _lock; the range check has no such constraint, so it now runs on the library actually being patched and the bool is_self[MAX_NATIVE_LIBS] array is gone.

_profiler_name and initialize()

_profiler_name is no longer used for identification and is removed.

initialize() stays, now setting an explicit flag rather than that string. Those _profiler_name == nullptr checks were doing double duty: besides the path comparison they gated patching on the profiler being up. Profiler::start() calls initialize() immediately before the first updateSymbols(), so in production the guard is always satisfied by then — but remove it and any earlier Libraries refresh installs pthread_create_hook before there is a profiler to register threads with. The reproducer then crashes deterministically:

SIGSEGV  libjavaProfiler.so+0x3a300  Profiler::registerThread(int)+0x20

(The full gtest suite passes with the guard removed, so tests alone do not catch this. The // only happens in Gtest comment on that guard is misleading.)

The flag is std::atomic<bool> with release/acquire, matching _socket_active: it is written from Profiler::start() and read from the Libraries refresher thread via patch_libraries(). initialize() resets _size before the release store, so a thread that observes the flag also observes the reset.

Note the gate is deliberately not added to patch_socket_functions(): that is only reachable from NativeSocketSampler::start() and from install_socket_hooks(), which early-returns unless _socket_active (false until the first batch), so it cannot run before start — and gating it would risk silently disabling socket patching if NativeSocketSampler::start() were ever ordered before LibraryPatcher::initialize().

Tests

New libraryPatcher_ut.cpp (7 tests). They are verified to fail without the fix: swapping the original realpath/strcmp logic back in makes exactly RecognisesSelfWhenItsLibraryFileWasUnlinked and LeavesItsOwnPthreadCreateSlotUntouched fail. StillPatchesForeignPthreadCreateSlot passes in both states as a control, so the guard is shown to discriminate rather than to have quietly disabled patching. The full :ddprof-lib:gtestDebug suite passes (54 test binaries, no failures).

Possible follow-up

Having pthread_create_hook() call the real function through a cached dlsym(RTLD_NEXT, ...) pointer instead of its own PLT — as patch_socket_functions() already does for send/recv/write/read — would make a self-patch harmless rather than merely prevented. Not included here to keep this change off the thread-creation hot path.


🤖 Generated with Claude Code

@hisener
hisener force-pushed the halil.sener/fix-library-patcher-self-patch branch from e29143e to 5c7c2d5 Compare August 6, 2026 15:34
@datadog-prod-us1-5

This comment has been minimized.

@hisener
hisener force-pushed the halil.sener/fix-library-patcher-self-patch branch from 5c7c2d5 to 64845c6 Compare August 6, 2026 17:24

@zhengyu123 zhengyu123 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_profiler_name is no longer used, so please remove it. You can also remove LibraryPatcher::initialize(), as its sole purpose was to initialize _profiler_name.

@hisener
hisener force-pushed the halil.sener/fix-library-patcher-self-patch branch from 64845c6 to 19572a3 Compare August 6, 2026 18:47
@hisener
hisener marked this pull request as ready for review August 6, 2026 19:55
@hisener
hisener requested a review from a team as a code owner August 6, 2026 19:55
@hisener
hisener requested a review from zhengyu123 August 6, 2026 19:55

@zhengyu123 zhengyu123 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@zhengyu123
zhengyu123 requested a review from Copilot August 6, 2026 20:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@zhengyu123
zhengyu123 requested a review from Copilot August 6, 2026 21:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@zhengyu123 zhengyu123 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, there is a similar problem in LibraryPatcher::patch_socket_functions()

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Suppressed comments (2)

ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp:433

  • This _initialized check needs an atomic load once _initialized is changed to std::atomic<bool>; otherwise it won’t compile and the intended cross-thread gate won’t be correctly synchronized.
void LibraryPatcher::patch_libraries() {
   // Profiler::start() has not run yet, so the hook would have nowhere to
   // register new threads. Also the case in Gtest, which never initializes.
   if (!_initialized) {
     return;
   }

ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp:511

  • This _initialized check needs an atomic load once _initialized is changed to std::atomic<bool>; otherwise it won’t compile and the intended cross-thread gate won’t be correctly synchronized.
void LibraryPatcher::patch_sigaction_in_library(CodeCache* lib) {
  if (lib->name() == nullptr) return;
  if (!_initialized) return;  // Not initialized yet

Comment on lines 24 to 31
static SpinLock _lock;
static const char* _profiler_name;
// Set by initialize(), which Profiler::start() calls just before the first
// library scan. Patching must not begin any earlier: pthread_create_hook()
// routes newly created threads through Profiler::registerThread(), which
// dereferences state that only exists once the profiler is running.
static bool _initialized;
static PatchEntry _patched_entries[MAX_NATIVE_LIBS];
static int _size;
Comment on lines 22 to 24
SpinLock LibraryPatcher::_lock;
const char* LibraryPatcher::_profiler_name = nullptr;
bool LibraryPatcher::_initialized = false;
PatchEntry LibraryPatcher::_patched_entries[MAX_NATIVE_LIBS];
LibraryPatcher recognised its own library by comparing realpath(lib) with
the profiler's path, and skipped that comparison entirely when realpath()
returned nullptr. dd-trace-java extracts libjavaProfiler.so to a temporary
file and unlinks it once loaded, so realpath() on the still-mapped path
fails and the self-check reported "not self" - letting a library re-scan
patch our own GOT entry for pthread_create. pthread_create_hook() reaches
the real pthread_create() through that same entry, so the hook then called
itself until the thread stack was exhausted: SIGSEGV with no hs_err file,
since crash reporting needs stack of its own. Whether it happened depended
on a re-scan landing after the unlink, which made it look random.

Recognise our own library by mapped address range instead, which cannot
fail. Every native library cache carries its mapping bounds, so no name
comparison is kept as a fallback. Apply it at all three patch sites:
pthread_create, sigaction and the socket functions.

patch_socket_functions() had the same hazard by another route. It computed
its is-self flags in a pre-pass keyed by library index and applied them in a
second, locked pass; the array can grow in between, so a flag could be
applied to the wrong entry and let us patch ourselves. The pre-pass only
existed because the old check called realpath() and could not run under the
lock, which no longer applies - the check now runs on the library actually
being patched.

_profiler_name is no longer used for identification, so drop it. The
"initialized yet?" guards it doubled as are still needed and now read an
explicit flag: patching must not start before Profiler::start(), because
pthread_create_hook() routes new threads through Profiler::registerThread(),
which crashes on a profiler that is not running. The flag is atomic with
release/acquire, being written from Profiler::start() and read from the
Libraries refresher thread.

Environment: Datadog workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hisener
hisener force-pushed the halil.sener/fix-library-patcher-self-patch branch from 19572a3 to e4225cf Compare August 7, 2026 09:06
@hisener
hisener requested a review from zhengyu123 August 7, 2026 09:19
@hisener

hisener commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Actually, there is a similar problem in LibraryPatcher::patch_socket_functions()

@zhengyu123 Claude made more changes to apply your comment and Codex comments: https://github.com/DataDog/java-profiler/compare/19572a3ef622ab14cd6715f616a91355ba2bc791..e4225cf51eb5f61ec3911f5cd6c5e0d544950ff7

@zhengyu123 zhengyu123 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@rkennke rkennke left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me, thank you!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (1)

ddprof-lib/src/test/cpp/libraryPatcher_ut.cpp:60

  • anchorMin()/anchorMax() do pointer arithmetic on a const char* derived from a function address. In C++ this is undefined behavior (pointer arithmetic is only defined within the same object/array), and can trip UBSan or stricter toolchains. Prefer doing the offset math via uintptr_t and cast back to const void*.
const void* anchorMin() {
  return (const char*)LibraryPatcherTestAccessor::selfAnchor() - 0x1000;
}

@zhengyu123
zhengyu123 merged commit f55119f into DataDog:main Aug 7, 2026
244 of 253 checks passed
jbachorik pushed a commit that referenced this pull request Aug 7, 2026
LibraryPatcher recognised its own library by comparing realpath(lib) with
the profiler's path, and skipped that comparison entirely when realpath()
returned nullptr. dd-trace-java extracts libjavaProfiler.so to a temporary
file and unlinks it once loaded, so realpath() on the still-mapped path
fails and the self-check reported "not self" - letting a library re-scan
patch our own GOT entry for pthread_create. pthread_create_hook() reaches
the real pthread_create() through that same entry, so the hook then called
itself until the thread stack was exhausted: SIGSEGV with no hs_err file,
since crash reporting needs stack of its own. Whether it happened depended
on a re-scan landing after the unlink, which made it look random.

Recognise our own library by mapped address range instead, which cannot
fail. Every native library cache carries its mapping bounds, so no name
comparison is kept as a fallback. Apply it at all three patch sites:
pthread_create, sigaction and the socket functions.

patch_socket_functions() had the same hazard by another route. It computed
its is-self flags in a pre-pass keyed by library index and applied them in a
second, locked pass; the array can grow in between, so a flag could be
applied to the wrong entry and let us patch ourselves. The pre-pass only
existed because the old check called realpath() and could not run under the
lock, which no longer applies - the check now runs on the library actually
being patched.

_profiler_name is no longer used for identification, so drop it. The
"initialized yet?" guards it doubled as are still needed and now read an
explicit flag: patching must not start before Profiler::start(), because
pthread_create_hook() routes new threads through Profiler::registerThread(),
which crashes on a profiler that is not running. The flag is atomic with
release/acquire, being written from Profiler::start() and read from the
Libraries refresher thread.

Environment: Datadog workspace

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit f55119f)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants